feat: multi round-trip requests — neutral contract and client auto-fulfilment engine#2313
Conversation
🦋 Changeset detectedLatest commit: dbb2a08 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…wire vocabulary The neutral InputRequest/InputResponse/InputRequiredResult types and the HandlerResultTypeMap; the inputRequired() builder family with its per-kind constructors, acceptedContent(), and withInputRequired(); the isInputRequiredResult() guard; the 2026-07-28 in-band wire schemas and the WireCodec.inputRequestSchema/inputResponseSchema accessors; spec-type parity checks and the SEP-2322 corpus pending-entry burn-down.
e461f95 to
fbb3dbb
Compare
The client half of multi round-trip requests, factored so the shared `Protocol` base stays generic: - protocol.ts gains only the irreducible input-required branch in the response path (manual mode via `allowInputRequired` plus a single named protected extension point `_resolveNonCompleteResult(decoded, flow)` whose base default is the existing typed UnsupportedResultType error), the type surface (`RequestOptions.allowInputRequired`, the `inputResponses`/`droppedInputResponseKeys`/`requestState` context fields), the `HandlerResultTypeMap` handler-return typing, and a `_getRequestHandler` accessor. - the engine body (driver invocation, embedded-request dispatch, synthesized-ctx construction, the inputResponses partition, and the per-retry-leg options whitelist) lives in `shared/inputRequiredEngine.ts`; the pure driver loop stays in `shared/inputRequiredDriver.ts`. - `Client` owns `ClientOptions.inputRequired` and overrides the hook to wire the engine. Includes three driver hardening fixes: each retry leg's request options are a whitelist (resumptionToken/onresumptiontoken/relatedRequestId never carry over); embedded sibling dispatches share a linked per-round abort so one failure cancels the others; the requestState-only pacing sleep honors the caller's abort signal.
fbb3dbb to
dbb2a08
Compare
| ...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout }) | ||
| }; | ||
| if (requestOptions.maxTotalTimeout !== undefined) { | ||
| const totalElapsed = Date.now() - startedAt; | ||
| const remaining = requestOptions.maxTotalTimeout - totalElapsed; | ||
| if (remaining <= 0) { | ||
| throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { | ||
| maxTotalTimeout: requestOptions.maxTotalTimeout, | ||
| totalElapsed | ||
| }); | ||
| } | ||
| legOptions.maxTotalTimeout = remaining; | ||
| } | ||
|
|
||
| const result = await hooks.retry(buildInputRequiredRetryParams(originalParams, responses, payload.requestState), legOptions); |
There was a problem hiding this comment.
🟡 When the maxTotalTimeout budget is already exhausted at the top of a round (e.g. the first wire leg alone overran it), runInputRequiredDriver still dispatches the round's embedded elicitation/sampling handlers — potentially showing a user-facing prompt — and only afterwards computes remaining <= 0 and throws RequestTimeout, discarding the answer. Performing the same budget check at the top of the loop, before dispatching handlers, avoids interactive work whose result is guaranteed to be thrown away.
Extended reasoning...
What the bug is
In runInputRequiredDriver (packages/core/src/shared/inputRequiredDriver.ts), the maxTotalTimeout accounting — totalElapsed = Date.now() - startedAt, remaining = maxTotalTimeout - totalElapsed, throw RequestTimeout when remaining <= 0 — sits after the round's dispatch block (the Promise.all over hooks.dispatchInputRequest, or the requestState-only pacing sleep) and immediately before hooks.retry(). Nothing checks the budget at the top of a round. So when the whole-flow budget is already exhausted before the round begins, the driver still dispatches the embedded elicitation/create / sampling/createMessage handlers — for elicitation that means showing a user-facing prompt and waiting for the user's answer — and only then throws RequestTimeout, throwing the answer away.
The code path that triggers it
The trigger is realistic, not test-only. The funnel does not enforce maxTotalTimeout with a wall-clock timer on a wire leg: in protocol.ts the only per-leg timer is the per-leg timeout (_setupTimeout), and maxTotalTimeout is consulted only inside _resetTimeout, which runs solely when resetTimeoutOnProgress is enabled and a progress notification arrives. With { timeout: 60_000, maxTotalTimeout: 5_000 } — exactly the combination in the PR's own engine test "counts the first wire leg against maxTotalTimeout (the budget bounds the whole flow)" — the first wire leg can legally take 10s and still resolve with input_required. The driver then enters round 1, immediately dispatches the embedded requests to the registered handlers, and only after the handlers settle does it discover the budget was gone before any of that work started. The same shape recurs on later rounds whose preceding retry leg overran its (shrunken) budget without emitting progress.
Why existing code doesn't prevent it
The check before hooks.retry() correctly prevents a doomed retry leg from reaching the wire (the driver test "the total-timeout budget is measured from the flow start" pins that), but there is no symmetric check before the dispatch block. flowStartedAt is threaded in precisely so the first leg counts against the budget, yet the first place that accounting is consulted is after the round's interactive work has already run.
Step-by-step proof (mirrors the PR's own client test)
client.callTool({ name: 'deploy' }, { timeout: 60_000, maxTotalTimeout: 5_000 })is issued at t=0;flowStartedAt = 0.- The server takes 10s and answers with
input_requiredcarrying anelicitation/createentry. The per-leg timeout (60s) never fires, andmaxTotalTimeoutis not enforced on a completed leg, so the funnel hands the payload to the driver at t=10s — 5s past the whole-flow budget. - The driver enters round 1 and calls
Promise.all([... dispatchInputRequest('github_login', ...)]): the registered elicitation handler runs — in a real client this is a prompt shown to the user, who types an answer. - Only after the handler resolves does the driver compute
remaining = 5_000 - (Date.now() - 0) <= 0and throwSdkError(RequestTimeout, { maxTotalTimeout: 5_000 }). The user's answer is discarded;toolCallsstays at 1 (as the test asserts). - The budget was provably exhausted before step 3 began, so the interactive work was guaranteed to be wasted.
Impact
No correctness impact on the final outcome — the call still rejects with the right typed RequestTimeout error, there is no leak or state corruption, and a budget that expires mid-prompt cannot be helped regardless. The cost is a pointless user-facing prompt (or sampling handler invocation) whose result is always thrown away, which contradicts the documented intent that "the budget bounds the WHOLE flow". Hence nit severity.
How to fix
Hoist the same remaining <= 0 computation to the top of the while loop (before the dispatch / pacing branch), in addition to keeping it before the retry leg. A small refactor — e.g. a local remainingBudget() helper called at both points — keeps the two checks from drifting.
| /** | ||
| * The handler-return counterpart of {@linkcode ResultTypeMap}: what a | ||
| * registered request handler may RETURN for each method. Identical to | ||
| * `ResultTypeMap` except that the multi-round-trip methods (`tools/call`, | ||
| * `prompts/get`, `resources/read`) additionally accept an | ||
| * {@linkcode InputRequiredResult} (protocol revision 2026-07-28). | ||
| * | ||
| * `ResultTypeMap` itself — what a *requester* receives — is deliberately NOT | ||
| * widened: `client.callTool()` returns a plain {@linkcode CallToolResult} on | ||
| * both protocol eras. | ||
| */ | ||
| export type HandlerResultTypeMap = { | ||
| [M in keyof ResultTypeMap]: M extends 'tools/call' | 'prompts/get' | 'resources/read' | ||
| ? ResultTypeMap[M] | InputRequiredResult | ||
| : ResultTypeMap[M]; | ||
| }; |
There was a problem hiding this comment.
🟡 The documented manual retry path can't be expressed through the typed surface: the changeset and RequestOptions.allowInputRequired's JSDoc tell callers to retry the original request with inputResponses/requestState params, but CallToolRequest['params'] / GetPromptRequest['params'] / ReadResourceRequest['params'] were not widened with those members (only the 2026 wire schemas gained retryParamsShape), so the PR's own manual-mode test has to cast with as Parameters<Client['callTool']>[0]. Consider a typed retry-params widening for the three MRTR methods (mirroring HandlerResultTypeMap on the requester side) or a dedicated typed retry helper.
Extended reasoning...
What the bug is
This PR documents a manual multi-round-trip flow: when a call is made with allowInputRequired: true (after inputRequired: { autoFulfill: false }), the input_required value is handed back and — per the RequestOptions.allowInputRequired JSDoc in protocol.ts and the changeset — "the caller is then responsible for gathering the requested input and retrying the original request with inputResponses / requestState params and a fresh request." That retry, however, cannot be written through the typed public surface: the neutral request param types the client methods accept (CallToolRequest['params'], GetPromptRequest['params'], ReadResourceRequest['params'] from packages/core/src/types/types.ts) have no inputResponses or requestState members, so passing them is an excess-property compile error.
The specific code path
CallToolRequestParamsSchema in packages/core/src/types/schemas.ts is TaskAugmentedRequestParamsSchema.extend({ name, arguments? }) built on a plain z.object (no catchall / index signature), so the inferred CallToolRequest['params'] has only _meta/task/name/arguments. The same holds for prompts/get and resources/read. The retry channel was added only to the 2026 wire-true schemas (retryParamsShape in wire/rev2026-07-28/schemas.ts), and those never reach the public method types — client.callTool() and the method-keyed client.request() overload (RequestTypeMap['tools/call']) are typed from the neutral schemas.
Why nothing else prevents it / proof
The PR's own manual-mode test demonstrates the gap. In packages/client/test/client/inputRequiredEngine.test.ts ("allowInputRequired: true hands the input-required value back to the caller, who can retry manually") the retry has to be written as:
const second = await client.callTool({
name: 'deploy',
arguments: {},
inputResponses: { github_login: { action: 'accept', content: { name: 'octocat' } } },
requestState: first.requestState as string
} as Parameters<Client['callTool']>[0]);Step-by-step: (1) a consumer follows the documented manual flow and writes the same object literal without the cast; (2) TypeScript checks it against CallToolRequest['params'], which has no inputResponses/requestState members; (3) excess-property checking rejects the literal — compile error; (4) the only ways forward are an unsafe as cast (what the SDK's own test does) or routing through an untyped path. At runtime everything works (the 2026 wire schema accepts the fields and they reach the wire, as the test shows), so this is purely a type-surface/DX gap — but it is a gap on the exact public flow this PR documents.
Why this is worth addressing
The handler-return side received a deliberate typed widening in this same PR (HandlerResultTypeMap, which adds InputRequiredResult to the three MRTR methods' return types), so the asymmetry is conspicuous: the documented requester-side retry got no equivalent treatment. That the SDK's own test must cast through Parameters<Client['callTool']>[0] is strong evidence consumers will hit the same wall.
How to fix
Either (a) widen the requester-side params for the three MRTR methods — e.g. a RetryAugmented<P> = P & { inputResponses?: InputResponses; requestState?: string } applied to tools/call / prompts/get / resources/read in the request type map and the callTool/getPrompt/readResource signatures, mirroring how HandlerResultTypeMap widened only the affected methods — or (b) ship a small typed retry helper (e.g. withRetryParams(params, { inputResponses, requestState })) so manual-mode callers never need an as cast. No runtime change is needed; this only affects the auto-fulfilment opt-out path.
| export function buildInputRequiredRetryParams( | ||
| originalParams: Record<string, unknown> | undefined, | ||
| responses: Record<string, unknown> | undefined, | ||
| requestState: string | undefined | ||
| ): Record<string, unknown> | undefined { | ||
| const hasResponses = responses !== undefined && Object.keys(responses).length > 0; | ||
| if (!hasResponses && requestState === undefined) { | ||
| return originalParams; | ||
| } | ||
| return { | ||
| ...originalParams, | ||
| ...(hasResponses && { inputResponses: responses }), | ||
| // Byte-exact echo: the opaque string is copied verbatim, never parsed. | ||
| // When the result carried no requestState, the retry carries none. | ||
| ...(requestState !== undefined && { requestState }) | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Abortable delay: resolves after `ms`, or rejects with the signal's reason |
There was a problem hiding this comment.
🟡 buildInputRequiredRetryParams spreads originalParams without stripping any pre-existing inputResponses/requestState keys, so when the originating call's params already carried them (the documented manual-retry shape: one allowInputRequired round, then a manual retry with { ...args, inputResponses, requestState } that the auto-fulfilment driver picks up), stale values leak into the driver's retries — a requestState-only round re-sends the old inputResponses verbatim, and a round whose payload carries no requestState still echoes the previous one, contradicting the inline comment 'When the result carried no requestState, the retry carries none'. Fix by stripping inputResponses/requestState from originalParams before spreading.
Extended reasoning...
What the bug is
buildInputRequiredRetryParams (packages/core/src/shared/inputRequiredDriver.ts:126-142) builds every retry leg as:
return {
...originalParams,
...(hasResponses && { inputResponses: responses }),
// Byte-exact echo: ... When the result carried no requestState, the retry carries none.
...(requestState !== undefined && { requestState })
};originalParams is flow.request.params passed verbatim by runInputRequiredFlow (inputRequiredEngine.ts), and the spread only overrides inputResponses when this round produced responses and requestState when this round's payload carried one. Nothing ever strips retry-channel members that were already present on the original params. So when the originating call itself carried inputResponses/requestState, those stale values survive into every driver retry that does not explicitly replace them.
How the trigger is reachable through documented API
The per-call allowInputRequired: true option is honored in protocol.ts before _resolveNonCompleteResult, regardless of the instance-level autoFulfill setting. So on a default (autoFulfill: true) client a caller can take the first round manually — exactly the shape the PR's own manual-mode test demonstrates: callTool({ ...args, inputResponses, requestState }) with the responses and echo placed directly into the params — and then issue that retry without allowInputRequired. If the server answers that retry with another input_required, the auto-fulfilment driver takes over with originalParams that already contain inputResponses and requestState from the previous (already-consumed) round.
What goes wrong
- Stale inputResponses on a requestState-only round. When a later round carries only
requestState(no embedded requests),responsesisundefined, so theinputResponsesoverride is skipped — and the spread re-sends the staleinputResponsesfrom the manual round, keyed to entries the server already consumed. - Stale requestState when the new payload carries none. When a round's
input_requiredcarries norequestState,requestStateisundefined, so the override is skipped — and the previous round'srequestStatefromoriginalParamsis echoed anyway. This directly contradicts the inline contract comment on line 139 ('When the result carried no requestState, the retry carries none') and the byte-exact-echo-of-the-LAST-result semantics. A server that integrity-protectsrequestState(HMAC/AEAD, as the PR's own docs recommend) and binds it to a round would reject the stale echo, producing confusing failures.
Step-by-step proof
- Client on default config (
autoFulfill: true) callscallTool({ name: 'deploy' }, { allowInputRequired: true }); server answersinput_requiredwithrequestState: 'r1'and an elicit entry. - Caller fulfils it manually and retries
callTool({ name: 'deploy', inputResponses: { confirm: {...} }, requestState: 'r1' })— noallowInputRequiredthis time. - Server answers this retry with another
input_required, this time carrying onlyinputRequests(norequestState). The driver runs withoriginalParams = { name: 'deploy', inputResponses: { confirm: {...} }, requestState: 'r1' }. - The driver fulfils the new requests and calls
buildInputRequiredRetryParams(originalParams, newResponses, undefined):inputResponsesis correctly replaced bynewResponses, butrequestState: 'r1'survives the spread and is sent — even though the last result carried norequestState. - Symmetrically, if the server's second answer were requestState-only (
inputRequestsempty),responseswould beundefinedand the staleinputResponses: { confirm: {...} }from step 2 would be re-sent verbatim.
Why existing code/tests don't catch it
The pure auto-fulfilment flow never puts retry-channel members into the original params (the driver always rebuilds from clean params, and retry legs use allowInputRequired so nested driver runs never happen), and the documented autoFulfill: false manual flow never reaches the driver at all. The unit tests for buildInputRequiredRetryParams only use clean original params, so the contamination path is untested.
Impact and fix
Impact is bounded: it requires hybrid manual-then-auto usage plus a multi-round server flow, and the server must treat the retry-channel members as untrusted anyway. Still, it is a genuine contract violation in code introduced by this PR, and the fix is trivial — strip inputResponses and requestState from originalParams before spreading (or explicitly delete them when the current round carries none), so the retry always reflects exactly the LAST result.
| const legOptions: InputRequiredRetryLegOptions = { | ||
| ...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout }) | ||
| }; | ||
| if (requestOptions.maxTotalTimeout !== undefined) { | ||
| const totalElapsed = Date.now() - startedAt; | ||
| const remaining = requestOptions.maxTotalTimeout - totalElapsed; | ||
| if (remaining <= 0) { | ||
| throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { | ||
| maxTotalTimeout: requestOptions.maxTotalTimeout, | ||
| totalElapsed | ||
| }); | ||
| } | ||
| legOptions.maxTotalTimeout = remaining; | ||
| } |
There was a problem hiding this comment.
🟡 On a retry leg the driver shrinks maxTotalTimeout to the remaining budget but passes the per-leg timeout through unchanged — and since the funnel only checks maxTotalTimeout inside _resetTimeout (i.e. when a progress notification arrives with resetTimeoutOnProgress), a no-progress retry leg can run for the full per-leg timeout (default 60s) even when only a few hundred milliseconds of the whole-flow budget remain, overrunning the bound documented by the new RequestOptions.maxTotalTimeout JSDoc ('the budget bounds the WHOLE flow') by up to one per-leg timeout per round. Consider clamping the per-leg timeout to the remaining budget, e.g. legOptions.timeout = Math.min(requestOptions.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC, remaining).
Extended reasoning...
What the bug is
In runInputRequiredDriver (packages/core/src/shared/inputRequiredDriver.ts:261-274), each retry leg is built as:
const legOptions: InputRequiredRetryLegOptions = {
...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout })
};
if (requestOptions.maxTotalTimeout !== undefined) {
const remaining = requestOptions.maxTotalTimeout - totalElapsed;
if (remaining <= 0) { throw RequestTimeout; }
legOptions.maxTotalTimeout = remaining;
}Only maxTotalTimeout is shrunk to the remaining budget; the per-leg timeout is the caller's original value, unchanged. The problem is that the shrunken maxTotalTimeout has no wall-clock enforcement of its own in the funnel: _setupTimeout (protocol.ts:620-635) arms a single setTimeout(onTimeout, timeout) for the per-leg timeout and merely stores maxTotalTimeout; the only place maxTotalTimeout is consulted is _resetTimeout (protocol.ts:637-648), which is invoked solely from the progress-notification path and only when resetTimeoutOnProgress is set (protocol.ts:1040-1042).
The code path
So with a server that emits no progress (or a caller that didn't set resetTimeoutOnProgress), the only thing bounding a retry leg is the per-leg timeout. Concretely: callTool({ name: 'deploy' }, { timeout: 60_000, maxTotalTimeout: 5_000 }) — the exact option combination in the PR's own engine test. The first leg takes 3s and answers input_required; the driver computes remaining = 2_000 and sends the retry with maxTotalTimeout: 2_000 but timeout: 60_000. The server answers after 30s (with another input_required or anything else); only at the next round top does the remaining <= 0 check throw. Total elapsed ≈ 33s against a 5s budget. Across maxRounds rounds the overrun can compound.
Why existing code/tests don't catch it
The PR's tests only exercise the round-top accounting: the driver tests mock Date.now() and the retry hooks resolve instantly, and the client-engine test ('counts the first wire leg against maxTotalTimeout') advances the mocked clock between calls, so the unenforced in-leg case is never hit.
What this contradicts
This PR adds the RequestOptions.maxTotalTimeout JSDoc: 'the budget bounds the WHOLE flow: every retry leg is given only the time remaining' (protocol.ts:130-132), and the driver module header makes the same promise ('maxTotalTimeout bounds the whole flow by shrinking the budget passed to each leg'). In the no-progress case the leg is in fact given the full per-leg timeout, not the time remaining.
On the refutation (why this is a nit, not nothing)
A refuting reviewer correctly points out that the underlying limitation — maxTotalTimeout being a progress-check-only knob rather than a wall-clock timer — is pre-existing funnel behavior, that the driver explicitly rides the existing knobs by design, and that the worst-case overrun is bounded at one per-leg timeout past the budget per round before the round-top check fires. All true, and the call still eventually fails with the correct typed RequestTimeout error. But the whole-flow-bound promise (the new JSDoc and module header) and the per-leg budget shrinking are introduced by this PR, and 'shrinking the budget passed to each leg' is only meaningful if the shrunken value is actually enforced — which it isn't on the path most likely to need it (a slow, non-progressing server). The refutation's concern that clamping timeout 'silently overrides the caller's per-leg timeout' is a fair design point, but a leg's effective deadline can never legitimately exceed the remaining whole-flow budget, so min(timeout, remaining) does not take anything away the caller could have used.
Step-by-step proof
client.callTool({ name: 'deploy' }, { timeout: 60_000, maxTotalTimeout: 5_000 })at t=0;flowStartedAt = 0.- The server answers at t=3s with
input_required(one elicit entry). The handler resolves instantly. - The driver reaches the budget block:
remaining = 5_000 - 3_000 = 2_000 > 0, solegOptions = { timeout: 60_000, maxTotalTimeout: 2_000 }and the retry is sent. - In
_requestWithSchemaViaCodecfor the retry leg,_setupTimeout(messageId, 60_000, 2_000, ...)arms only the 60s timer;maxTotalTimeout: 2_000sits unused because the server emits no progress. - The server answers the retry at t=33s with another
input_required. The leg resolves normally — no timer fired. - Only now does the driver's next round-top check compute
remaining = 5_000 - 33_000 <= 0and throwRequestTimeout. The 5s budget was overrun by 28s.
How to fix
Clamp the per-leg timeout to the remaining budget when one exists:
legOptions.timeout = Math.min(requestOptions.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC, remaining);(or equivalently set legOptions.timeout = remaining only when it's smaller). This keeps the existing-knobs design — no new timer system — while making the documented whole-flow bound hold within one leg's resolution.
This adds the client half of multi round-trip requests (SEP-2322, protocol revision 2026-07-28): the neutral
InputRequest/InputResponse/InputRequiredResult types, the
inputRequired()builder family, and a client engine thatfulfils
input_requiredresults automatically through the already-registered elicitation/sampling/roots handlers andretries the original call with
inputResponses, a byte-exactrequestStateecho, and a fresh request id.Motivation and Context
The 2026-07-28 draft removes the server→client JSON-RPC request channel; servers obtain client input in-band by
answering tools/call, prompts/get, or resources/read with an
input_requiredresult. Clients need a way to fulfilthose results without changing their call sites.
How Has This Been Tested?
abort, abortable pacing sleep, the per-leg options whitelist), the manual path, the inbound
inputResponsespartition, and the synthesized embedded dispatch context.
flows, missing-handler/unknown-kind failures, manual mode, the synthesized handler context).
Breaking Changes
None for 2025-era traffic. On 2026-07-28 connections,
input_requiredanswers are now fulfilled automatically bydefault;
inputRequired: { autoFulfill: false }and the per-callallowInputRequiredoption restore manual handling.Types of changes
Checklist
Additional context
client.callTool()keeps returning a plainCallToolResult; the interactive rounds happen inside the call.Protocolbase stays generic: it carries only theinput_requiredbranch in the response path, a singlenamed protected extension point
_resolveNonCompleteResult(decoded, flow)(base default = the typedUnsupportedResultTypeerror), and the type surface; the engine body lives inshared/inputRequiredEngine.tsandthe pure loop in
shared/inputRequiredDriver.ts.ClientownsClientOptions.inputRequiredand overrides the hook.(resumption tokens never carry over); embedded sibling dispatches share a linked abort.
requestStateis treated as an opaque string end to end (echoed byte-exact, never parsed).